package com.abso.sunlight.api;

import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpException;
import org.apache.commons.httpclient.HttpMethodBase;
import org.apache.commons.httpclient.HttpStatus;
import org.apache.commons.httpclient.SimpleHttpConnectionManager;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.httpclient.params.HttpMethodParams;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;
import org.xml.sax.InputSource;

/**
 * The Sunlight service.
 */
public class SunlightService {

    /** HTTP connection default time-out (in milliseconds). */
    public static final int DEFAULT_TIMEOUT = 20000;

    /** The current time-out (default = DEFAULT_TIMEOUT). */
    private int timeout = DEFAULT_TIMEOUT;

    /** The license key issued by Sunlight Labs. */
    private String licenseKey;

    /**
     * Constructs a new service.
     * 
     * @param licenseKey
     *            the license key issued by Sunlight Labs.
     */
    public SunlightService(String licenseKey) {
        super();
        this.licenseKey = licenseKey;
    }

    /**
     * Sets the timeout.
     * 
     * @param timeout
     *            the timeout.
     * @see #DEFAULT_TIMEOUT
     */
    public void setTimeout(int timeout) {
        this.timeout = timeout;
    }

    /**
     * Performs a fuzzy-matching text search of the legislators.
     * 
     * @param name
     *            the name to search for.
     * @param threshold
     *            an optional threshold parameter specifying minimum score to return, between 0.0 and 1.0 (the minimum suggested value
     *            is 0.8, lower values not recommended)
     * @return the list of matching legislators.
     * @throws SunlightException
     *             if an exception occurred invoking the service.
     */
    public List<Legislator> getLegislatorsByName(String name, String threshold) throws SunlightException {
        Map<String, String[]> params = new HashMap<String, String[]>();
        params.put("name", new String[] { name });
        params.put("threshold", new String[] { threshold });
        Document doc = execute("legislators.search", params);
        List<Legislator> legislators = new ArrayList<Legislator>();
        for (Iterator<?> i = doc.selectNodes("response/results/result/legislator").iterator(); i.hasNext();) {
            Element elem = (Element) i.next();
            legislators.add(new Legislator(elem));
        }
        return legislators;
    }

    /**
     * Returns the legislators matching the specified query.
     * 
     * @param query
     *            the query to match.
     * @return the list of matching legislators.
     * @throws SunlightException
     *             if an exception occurred invoking the service.
     */
    public List<Legislator> getLegislators(LegislatorQuery query) throws SunlightException {
        Document doc = execute("legislators.getList", query.getParameters());
        List<Legislator> legislators = new ArrayList<Legislator>();
        for (Iterator<?> i = doc.selectNodes("response/legislators/legislator").iterator(); i.hasNext();) {
            Element elem = (Element) i.next();
            legislators.add(new Legislator(elem));
        }
        return legislators;
    }

    /**
     * Returns the legislators that currently represent some portion of a zipcode.
     * 
     * @param zipcode
     *            a five-digit zip code.
     * @return the list of matching legislators.
     * @throws SunlightException
     *             if an exception occurred invoking the service.
     */
    public List<Legislator> getLegislatorsForZip(String zipcode) throws SunlightException {
        zipcode = normalizeZipCode(zipcode);
        Map<String, String[]> params = new HashMap<String, String[]>();
        params.put("zip", new String[] { zipcode });
        Document doc = execute("legislators.allForZip", params);
        List<Legislator> legislators = new ArrayList<Legislator>();
        for (Iterator<?> i = doc.selectNodes("response/legislators/legislator").iterator(); i.hasNext();) {
            Element elem = (Element) i.next();
            legislators.add(new Legislator(elem));
        }
        return legislators;
    }

    /**
     * Returns all the districts that overlap the area for a given zip code.
     * 
     * @param zipcode
     *            a five-digit zip code.
     * @return the list of matching districts.
     * @throws SunlightException
     *             if an exception occurred invoking the service.
     */
    public List<District> getDistrictsForZip(String zipcode) throws SunlightException {
        zipcode = normalizeZipCode(zipcode);
        Map<String, String[]> params = new HashMap<String, String[]>();
        params.put("zip", new String[] { zipcode });
        Document doc = execute("districts.getDistrictsFromZip", params);
        List<District> districts = new ArrayList<District>();
        for (Iterator<?> i = doc.selectNodes("response/districts/district").iterator(); i.hasNext();) {
            Element elem = (Element) i.next();
            districts.add(new District(elem));
        }
        return districts;
    }

    /**
     * Returns all the zip codes within a given congressional district.
     * 
     * @param state
     *            a state; use State.valueOfAbbreviation() to obtain a state from its 2-digit abbreviation.
     * @param districtNumber
     *            a district number (0 for At-Large).
     * @return the list of 5-digit zip codes.
     * @throws SunlightException
     *             if an exception occurred invoking the service.
     * @see State#valueOfAbbreviation(String)
     */
    public List<String> getZipsFromDistrict(State state, int districtNumber) throws SunlightException {
        Map<String, String[]> params = new HashMap<String, String[]>();
        params.put("state", new String[] { state.getAbbreviation() });
        params.put("district", new String[] { Integer.toString(districtNumber) });
        Document doc = execute("districts.getZipsFromDistrict", params);
        List<String> zips = new ArrayList<String>();
        for (Iterator<?> i = doc.selectNodes("response/zips/zip").iterator(); i.hasNext();) {
            Element elem = (Element) i.next();
            zips.add(elem.getText());
        }
        Collections.sort(zips);
        return zips;
    }

    /**
     * Returns the district that a provided latitude/longitude pair falls within.
     * 
     * @param latitude
     *            latitude of coordinate.
     * @param longitude
     *            longitude of coordinate.
     * @return the matching district.
     * @throws SunlightException
     *             if an exception occurred invoking the service.
     */
    public District getDistrictFromLatLong(double latitude, double longitude) throws SunlightException {
        Map<String, String[]> params = new HashMap<String, String[]>();
        params.put("latitude", new String[] { Double.toString(latitude) });
        params.put("longitude", new String[] { Double.toString(longitude) });
        Document doc = execute("districts.getDistrictFromLatLong", params);
        Element elem = (Element) doc.selectSingleNode("response/districts/district");
        if (elem != null) {
            return new District(elem);
        } else {
            return null;
        }
    }

    /**
     * Gives a record containing the information from one Senate Office of Public Records lobbyist disclosure filing.
     * 
     * @param id
     *            unique id that the Senate assigned to the filing.
     * @return the filing.
     * @throws SunlightException
     *             if an exception occurred invoking the service.
     */
    public Filing getFiling(String id) throws SunlightException {
        Map<String, String[]> params = new HashMap<String, String[]>();
        params.put("id", new String[] { id });
        Document doc = execute("lobbyists.getFiling", params);
        Element elem = (Element) doc.selectSingleNode("response/filing");
        if (elem != null) {
            return new Filing(elem);
        } else {
            return null;
        }
    }

    /**
     * Retrieves multiple filings at once.
     * 
     * @param clientName
     *            client name to search for.
     * @param registrantName
     *            registrant name to search for.
     * @param year
     *            to limit search to (1999 or later); year is optional but strongly recommended as not including it may return a
     *            massive result set
     * @return the filings.
     * @throws SunlightException
     *             if an exception occurred invoking the service.
     */
    public List<Filing> getFilingList(String clientName, String registrantName, String year) throws SunlightException {
        Map<String, String[]> params = new HashMap<String, String[]>();
        if (!StringUtils.isBlank(clientName)) {
            params.put("client_name", new String[] { clientName });
        }
        if (!StringUtils.isBlank(registrantName)) {
            params.put("registrant_name", new String[] { registrantName });
        }
        if (!StringUtils.isBlank(year)) {
            params.put("year", new String[] { year });
        }
        Document doc = execute("lobbyists.getFilingList", params);
        List<Filing> filings = new ArrayList<Filing>();
        for (Iterator<?> i = doc.selectNodes("response/filings/filing").iterator(); i.hasNext();) {
            Element elem = (Element) i.next();
            filings.add(new Filing(elem));
        }
        return filings;
    }

    /**
     * Performs a fuzzy-matching text search of the lobbyist records.
     * 
     * @param name
     *            the name to search for.
     * @param year
     *            the year to limit search to (defaults to current year).
     * @param threshold
     *            optional threshold parameter specifying minimum score to return (defaults to 0.9, strongly recommended not to go much
     *            lower)
     * @return the list of lobbyists.
     * @throws SunlightException
     *             if an exception occurred invoking the service.
     */
    public List<Lobbyist> getLobbysts(String name, String year, String threshold) throws SunlightException {
        Map<String, String[]> params = new HashMap<String, String[]>();
        if (!StringUtils.isBlank(name)) {
            params.put("name", new String[] { name });
        }
        if (!StringUtils.isBlank(year)) {
            params.put("year", new String[] { year });
        }
        if (!StringUtils.isBlank(threshold)) {
            params.put("threshold", new String[] { threshold });
        }
        Document doc = execute("lobbyists.search", params);
        List<Lobbyist> lobbyists = new ArrayList<Lobbyist>();
        for (Iterator<?> i = doc.selectNodes("response/results/result").iterator(); i.hasNext();) {
            Element elem = (Element) i.next();
            lobbyists.add(new Lobbyist(elem));
        }
        return lobbyists;
    }

    /**
     * Executes a REST method.
     * 
     * @param methodName
     *            the name of the method.
     * @param params
     *            the set of parameters being appended to the request.
     * @return the resulting XML document.
     * @throws SunlightException
     *             if an error occurred executing the method.
     */
    private Document execute(String methodName, Map<String, String[]> params) throws SunlightException {
        StringBuffer buffer = new StringBuffer();
        buffer.append("http://services.sunlightlabs.com/api/");
        buffer.append(methodName);
        buffer.append(".xml?apikey=");
        buffer.append(licenseKey);
        if (!params.isEmpty()) {
            buffer.append('&');
            for (Map.Entry<String, String[]> param : params.entrySet()) {
                for (String value : param.getValue()) {
                    try {
                        buffer.append(encodeURLParameter(param.getKey()));
                    } catch (UnsupportedEncodingException e) {
                        throw new SunlightException("Unable to encode parameter: " + param.getKey(), e);
                    }
                    buffer.append('=');
                    try {
                        buffer.append(encodeURLParameter(value));
                    } catch (UnsupportedEncodingException e) {
                        throw new SunlightException("Unable to encode parameter: " + param.getValue(), e);
                    }
                    buffer.append('&');
                }
            }
        }
        String url = StringUtils.removeEnd(buffer.toString(), "&");
        SimpleHttpConnectionManager connMgr = new SimpleHttpConnectionManager();
        connMgr.getParams().setConnectionTimeout(timeout);
        connMgr.getParams().setSoTimeout(timeout);
        connMgr.getParams().setParameter(HttpMethodParams.USER_AGENT, "Sunlight Java/Eclipse plug-in (http://abso.freehostia.com)");
        HttpMethodBase httpMethod = new GetMethod(url.toString());
        HttpClient httpClient = new HttpClient(connMgr);
        InputStream inStream = null;
        try {
            int responseCode = httpClient.executeMethod(httpMethod);
            if (responseCode != HttpStatus.SC_OK) {
                StringBuffer errorMsg = new StringBuffer();
                errorMsg.append(httpMethod.getStatusCode());
                errorMsg.append(" - ");
                errorMsg.append(HttpStatus.getStatusText(httpMethod.getStatusCode()));
                errorMsg.append(" - ");
                errorMsg.append(StringUtils.defaultString(httpMethod.getResponseBodyAsString()));
                throw new SunlightException(errorMsg.toString());
            }
            SAXReader reader = new SAXReader();
            inStream = httpMethod.getResponseBodyAsStream();
            InputSource source = new InputSource(inStream);
            source.setEncoding("UTF-8");
            Document doc = reader.read(source);
            return doc;
        } catch (HttpException e) {
            throw new SunlightException("Unable to perform request: " + e.getMessage(), e);
        } catch (IOException e) {
            throw new SunlightException("Unable to perform request: " + e.getMessage(), e);
        } catch (DocumentException e) {
            throw new SunlightException("Unable to parse response of request: " + e.getMessage(), e);
        } finally {
            httpMethod.releaseConnection();
            IOUtils.closeQuietly(inStream);
        }
    }

    /**
     * Encodes a URL parameter using the UTF-8 encoding.
     * 
     * @param param
     *            the parameter being encoded.
     * @return the encoded parameter.
     * @throws UnsupportedEncodingException
     *             if the parameter cannot be encoded.
     */
    private String encodeURLParameter(String param) throws UnsupportedEncodingException {
        String encodedPath = URLEncoder.encode(param, "UTF8");
        encodedPath = encodedPath.replaceAll("\\+", "%20");
        return encodedPath;
    }

    /**
     * Attempts to normalize a zip code (if coming from a Integer.toString() invocation).
     * 
     * @param zipcode
     *            the zip code to normalize.
     * @return the normalized zip code.
     */
    private String normalizeZipCode(String zipcode) {
        if (zipcode.length() < 5) {
            zipcode = StringUtils.repeat("0", 5 - zipcode.length()) + zipcode;
        }
        return zipcode;
    }

}
